feat: auto-restore terminal commands on session restore (part 1/2)#2545
feat: auto-restore terminal commands on session restore (part 1/2)#2545mrosnerr wants to merge 31 commits intomanaflow-ai:mainfrom
Conversation
|
@mrosnerr is attempting to deploy a commit to the Manaflow Team on Vercel. A member of the Team first needs to authorize it. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdd per-surface one-shot typed startup input for local terminals (consumed and cleared after first native surface creation); capture and validate per-TTY foreground commands with allowlist/denylist rules; persist per-panel restore state (including detectedCommand/isRemoteBacked) and inject validated restore text as Changes
Sequence DiagramsequenceDiagram
participant User
participant App as AppDelegate
participant Workspace
participant Cache as SessionForegroundProcessCache
participant Detector as SessionForegroundProcessDetector
participant Snapshot as SessionPersistence
participant Surface as TerminalSurface
participant Shell
rect rgba(100,150,200,0.5)
Note over User,App: Snapshot capture
User->>App: trigger saveSessionSnapshot
App->>Workspace: buildSessionSnapshot
Workspace->>Cache: refresh(ttyNames) (async/sync)
Cache->>Detector: scan tty names
Detector->>Detector: run /bin/ps and sysctl(KERN_PROCARGS2)
Detector-->>Cache: store detected command for tty
Cache-->>Snapshot: include detectedCommand (if allowed)
end
rect rgba(150,100,200,0.5)
Note over User,Surface: Restore flow
User->>Workspace: restore session
Workspace->>Snapshot: read panelSnapshot
Snapshot->>Snapshot: SessionRestoreCommandSettings.validatedRestoreCommand(...)
Snapshot-->>Workspace: allowed restore text (if any)
Workspace->>Surface: create terminal with initialInput (text + "\n")
Surface->>Shell: inject initialInput at startup
Shell->>Shell: execute restored input
Surface-->>Workspace: clear initialInput after first native surface creation
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryAdds auto-restore infrastructure for terminal session commands: foreground process detection via Confidence Score: 5/5Safe to merge; the one remaining finding is a P2 that only affects future SSH support, not current functionality. All P0/P1 concerns from prior rounds are resolved. The only unfixed issue is a P2 false-positive in denylistContains ("-p ") that doesn't affect any currently-allowed command and only becomes relevant when Part 2 adds SSH. Test coverage is thorough and the core detection/restore pipeline is logically sound. Sources/SessionPersistence.swift — specifically the denylistContains "-p " and "-P " entries if SSH support is added in Part 2. Important Files Changed
Sequence DiagramsequenceDiagram
participant App as AppDelegate
participant Cache as SessionForegroundProcessCache
participant Detector as SessionForegroundProcessDetector
participant Workspace as Workspace
participant Terminal as TerminalSurface
Note over App: On quit (isTerminatingApp)
App->>Cache: refreshSync(ttyNames:, timeout: 2s)
Cache->>Detector: detect(forTTY:) × N
Detector->>Detector: ps -t ttyName → processSnapshots
Detector->>Detector: KERN_PROCARGS2 → commandLineString(pid)
Detector-->>Cache: ForegroundProcess (if allowlisted)
Cache-->>App: cache updated (or timeout → use old cache)
App->>Workspace: buildSessionSnapshot()
Workspace->>Cache: cachedCommandLine(forTTY:)
Cache-->>Workspace: detectedCommand (String?)
Workspace-->>App: SessionTerminalPanelSnapshot(detectedCommand:, restoreCommand:)
Note over App: On restore
App->>Workspace: restoreFromSnapshot()
Workspace->>Workspace: commandToRestore = isEnabled ? restoreCommand ?? detectedCommand : nil
Workspace->>Workspace: safeInitialInput = remoteStartupCmd != nil ? nil : commandToRestore
Workspace->>Terminal: newTerminalSurface(initialInput: safeInitialInput)
Terminal->>Terminal: initialInput + "\n" → Ghostty surface on first create
Terminal->>Terminal: initialInput = nil (cleared after use)
Reviews (5): Last reviewed commit: "Block commands with sensitive credential..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (2)
cmuxTests/SessionPersistenceTests.swift (1)
1196-1203: Consider adding a test that verifies destructive commands aren't matched by default patterns.The current test verifies that literal patterns like
"rm *"aren't indefaultAllowlistPatterns, but doesn't verify thatisCommandAllowed("rm -rf /", rawAllowlist: defaultRawAllowlist)returnsfalse. If a future change added an overly broad pattern, this test wouldn't catch it.💡 Suggested additional test
func testDefaultAllowlistDoesNotMatchDestructiveCommands() { let defaultRaw = SessionRestoreCommandSettings.defaultAllowlistPatterns.joined(separator: "\n") XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("rm -rf /", rawAllowlist: defaultRaw)) XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("sudo rm -rf /", rawAllowlist: defaultRaw)) XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("git push --force", rawAllowlist: defaultRaw)) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1196 - 1203, Add a new unit test that asserts the default allowlist patterns do not match destructive commands: use SessionRestoreCommandSettings.defaultAllowlistPatterns joined into a rawAllowlist string and call SessionRestoreCommandSettings.isCommandAllowed(...) with inputs like "rm -rf /", "sudo rm -rf /", and "git push --force", asserting each returns false; place this alongside the existing testDefaultAllowlistExcludesDestructiveCommands (e.g., name it testDefaultAllowlistDoesNotMatchDestructiveCommands) so the suite covers both absence of literal patterns and actual matching behaviour.Sources/SessionPersistence.swift (1)
546-546: Empty allowlist falls back to defaults instead of disabling all restores.If a user intentionally clears the allowlist (e.g., sets it to whitespace or all comments) to disable command restoration entirely, the current logic falls back to
defaultAllowlistPatterns. This may not match user intent.Consider whether an empty parsed result should mean "allow nothing" (return
[]) rather than "use defaults". If you want to preserve the current behavior, document it clearly in part 2's Settings UI.♻️ Alternative: respect empty allowlist as "allow nothing"
static func normalizedAllowlistPatterns(rawValue: String?) -> [String] { let source: String if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source = rawValue } else { source = defaultAllowlistText } let parsed = parsePatterns(from: source) - return parsed.isEmpty ? defaultAllowlistPatterns : parsed + // If user provided a rawValue but it parsed to empty, respect that as "allow nothing" + if rawValue != nil && parsed.isEmpty { + return [] + } + return parsed.isEmpty ? defaultAllowlistPatterns : parsed }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/SessionPersistence.swift` at line 546, The current return uses defaultAllowlistPatterns when parsed is empty which overrides an intentional empty allowlist; change the logic in SessionPersistence (the code returning parsed.isEmpty ? defaultAllowlistPatterns : parsed) so that an explicitly-empty parsed result is respected (return parsed or return [] when parsed.isEmpty) and only fall back to defaultAllowlistPatterns when parsing failed/parsed is nil; update any related docstring/comment in the function and adjust tests/UI for the Settings allowlist behavior to reflect that an empty allowlist disables restores.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3890-3893: The DEBUG log currently prints full startup command
content via dlog using resolvedInitialInput, initialInput, and
baseConfig.initialInput which can leak sensitive data and break log framing;
update the logging in the GhosttyTerminalView startup path so dlog only emits a
redacted summary (e.g., presence flags and byte/line counts or a short hash) for
resolvedInitialInput/initialInput/baseConfig.initialInput and include the
surface id (id.uuidString.prefix(5)) and which inputs are present rather than
their full debugDescription, ensuring multiline input is not printed.
- Around line 3882-3889: The restored initial input can be replayed on
subsequent surface recreations; make initialInput mutable on TerminalSurface
(and allow baseConfig.initialInput to be overridden) and consume it after the
first successful surface creation inside createSurface(for:): after calling
ghostty_surface_new(...) clear/set TerminalSurface.initialInput = nil (and if
you use baseConfig.initialInput, ensure you also set that source to nil or
prefer the mutable per-surface initialInput) so the restore command is only
applied once.
In `@Sources/SessionPersistence.swift`:
- Around line 653-679: processSnapshots(forTTY:) blocks the main thread by
calling process.run() + waitUntilExit(); change it to perform the ps invocation
off the main queue (e.g., DispatchQueue.global().async) and use a
terminationHandler or readDataToEndOfFileInBackground + a completion callback
(or make processSnapshots async/throwing) so callers like sessionPanelSnapshot,
sessionSnapshot, buildSessionSnapshot and saveSessionSnapshot do not block UI
during autosave/quit; alternatively, if you cannot change call sites, document
and assert that detect(forTTY:) / processSnapshots(forTTY:) must only be called
from a background queue and add an assertion to catch accidental main‑thread
usage.
In `@Sources/Workspace.swift`:
- Around line 5570-5571: The new per-panel state panelRestoreCommands is not
included in the panel-metadata lifecycle causing it to be lost on moves and
orphaned on prune; update the DetachedSurfaceTransfer type (or wherever surface
metadata is packaged) to carry panelRestoreCommands for transferred panels and
ensure pruneSurfaceMetadata includes panelRestoreCommands when deciding which
per-panel entries to keep or remove (also update any remapping logic that
translates old panel UUIDs to new ones so panelRestoreCommands keys are remapped
alongside other metadata).
- Around line 446-450: sessionSnapshot() in Workspace (which is `@MainActor`) is
calling SessionForegroundProcessDetector.detect(forTTY:) synchronously (via
detectedCommand) causing /bin/ps to run on the main actor and stall UI; instead,
run detection off the main actor and read a cached value here. Implement an
async/background cache for SessionForegroundProcessDetector results (e.g. a
detached Task, an actor or a background queue updater) keyed by
surfaceTTYNames[panelId] and expose a synchronous cached property or method
(e.g. foregroundCommandCache[tty] or
SessionForegroundProcessDetectorCache.getCachedCommand(forTTY:)) that
sessionSnapshot() can read without blocking; update the cache periodically or on
relevant events and replace the direct call to
SessionForegroundProcessDetector.detect(forTTY:) in Workspace.sessionSnapshot()
with a read from that cache (use panelId and surfaceTTYNames to form the key).
---
Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1196-1203: Add a new unit test that asserts the default allowlist
patterns do not match destructive commands: use
SessionRestoreCommandSettings.defaultAllowlistPatterns joined into a
rawAllowlist string and call SessionRestoreCommandSettings.isCommandAllowed(...)
with inputs like "rm -rf /", "sudo rm -rf /", and "git push --force", asserting
each returns false; place this alongside the existing
testDefaultAllowlistExcludesDestructiveCommands (e.g., name it
testDefaultAllowlistDoesNotMatchDestructiveCommands) so the suite covers both
absence of literal patterns and actual matching behaviour.
In `@Sources/SessionPersistence.swift`:
- Line 546: The current return uses defaultAllowlistPatterns when parsed is
empty which overrides an intentional empty allowlist; change the logic in
SessionPersistence (the code returning parsed.isEmpty ? defaultAllowlistPatterns
: parsed) so that an explicitly-empty parsed result is respected (return parsed
or return [] when parsed.isEmpty) and only fall back to defaultAllowlistPatterns
when parsing failed/parsed is nil; update any related docstring/comment in the
function and adjust tests/UI for the Settings allowlist behavior to reflect that
an empty allowlist disables restores.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f34d8278-5d40-498c-b49a-bcbee7632b91
📒 Files selected for processing (5)
Sources/GhosttyTerminalView.swiftSources/Panels/TerminalPanel.swiftSources/SessionPersistence.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
There was a problem hiding this comment.
4 issues found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/SessionPersistence.swift">
<violation number="1" location="Sources/SessionPersistence.swift:522">
P1: `"watch *"` is overly broad for the default allowlist because it permits restoring arbitrary subcommands, including destructive ones.</violation>
<violation number="2" location="Sources/SessionPersistence.swift:737">
P2: Command reconstruction treats raw bytes as UnicodeScalars and joins argv with spaces, corrupting UTF‑8 arguments and losing argument boundaries/escaping during auto-restore.</violation>
<violation number="3" location="Sources/SessionPersistence.swift:745">
P2: Reconstructing argv with a plain space join loses quoting, so arguments that contain spaces will be split into different parameters when the command is restored.</violation>
</file>
<file name="Sources/Workspace.swift">
<violation number="1" location="Sources/Workspace.swift:5571">
P2: panelRestoreCommands is stored per panel but never transferred in DetachedSurfaceTransfer, so detaching/reattaching a terminal panel drops the explicit restore command.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 3712-3714: saveSessionSnapshot is `@MainActor` but calling
SessionForegroundProcessCache.refresh(ttyNames:) synchronously causes per‑TTY ps
scans on the UI thread; move the refresh off the main actor. Change the call to
run on a background queue (e.g. DispatchQueue.global()/Task.detached) and await
its completion if the snapshot depends on the results, or run it fire-and-forget
if not required for the immediate snapshot, so refreshForegroundProcessCache()
executes off the main thread instead of inline in saveSessionSnapshot.
In `@Sources/Workspace.swift`:
- Around line 660-665: When restoring a terminal, avoid passing initialInput
when the startup command is an SSH invocation: detect if
remoteTerminalStartupCommand() is non-nil and skip supplying commandToRestore to
newTerminalSurface’s initialInput parameter; instead call newTerminalSurface
with the same arguments but omit or pass nil for initialInput (locations to
update include the newTerminalSurface invocation around
paneId/workingDirectory/replayEnvironment/commandToRestore and the analogous
block around the other occurrence referenced at lines ~7716-7740). Ensure
initialCommand/initialInput logic distinguishes local restores from remote SSH
startups so the restore text is not sent to the ssh process.
- Around line 645-652: The code currently reads
snapshot.terminal?.restoreCommand and may persist the raw value into
panelRestoreCommands even when disallowed; change the logic so you only ever
write the trimmed, allowlisted command into panelRestoreCommands via
SessionRestoreCommandSettings (or skip writing entirely) — i.e., trim the value,
check SessionRestoreCommandSettings.isCommandAllowed(trimmed) and only
assign/save trimmed when allowed, otherwise do not copy the snapshot value into
panelRestoreCommands (and ensure the same fix is applied to the other occurrence
that uses snapshot.terminal?.restoreCommand).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 325763af-9fa5-473d-a984-b2e7b60b7115
📒 Files selected for processing (6)
Sources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/SessionPersistence.swiftSources/TabManager.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
✅ Files skipped from review due to trivial changes (1)
- cmuxTests/SessionPersistenceTests.swift
🚧 Files skipped from review as they are similar to previous changes (2)
- Sources/GhosttyTerminalView.swift
- Sources/SessionPersistence.swift
There was a problem hiding this comment.
1 issue found across 6 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="cmuxTests/SessionPersistenceTests.swift">
<violation number="1" location="cmuxTests/SessionPersistenceTests.swift:1207">
P2: `testDenylistBlocksSystemCommands` does not isolate denylist behavior because it uses the default allowlist, so it can pass from allowlist rejection even if denylist rules regress.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
Sources/AppDelegate.swift (1)
3712-3713:⚠️ Potential issue | 🟠 Major
group.wait()keeps snapshot saves UI-blocking.
saveSessionSnapshotstill runs on@MainActor, andrefreshForegroundProcessCacheSync()waits synchronously forSessionForegroundProcessCache.refresh(...), which is itself a blocking per-TTYpsloop. Autosave, resign-active, and quit-time saves can still stall the app until the full scan finishes. Please either refresh ahead of time and consume the last completed cache here, or move the refresh+save pipeline off the main actor.Also applies to: 3988-4000
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 3712 - 3713, The call to refreshForegroundProcessCacheSync() blocks the main thread because saveSessionSnapshot remains on `@MainActor` while SessionForegroundProcessCache.refresh(...) does a per-TTY ps loop; change the flow so the snapshot save does not wait for that blocking refresh: either (A) pre-run the refresh on a background queue and have refreshForegroundProcessCacheSync() read and return the last completed cache (consume cache snapshot instead of running refresh synchronously) or (B) move the refresh+save pipeline off the main actor entirely by performing SessionForegroundProcessCache.refresh(...) on a background Task/DispatchQueue and then call saveSessionSnapshot only after that background work completes (or call a non-@MainActor helper that collects data then dispatches the minimal UI-bound work back to `@MainActor`). Update refreshForegroundProcessCacheSync, SessionForegroundProcessCache.refresh, and saveSessionSnapshot call sites to use the chosen approach so the UI is not blocked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/Workspace.swift`:
- Around line 235-242: allTerminalTTYNames() currently returns TTYs from
surfaceTTYNames which may be rehydrated from snapshots (see
applySessionPanelMetadata), causing stale TTYs to be used; change the logic to
only include TTY names that come from a live surface (e.g., check the panel
instance for a "hasLiveTTY"/"isLive" flag or only read TTYs that the
TerminalPanel reports as live) or clear/stage restored entries when
applySessionPanelMetadata populates surfaceTTYNames so they are not returned
until the terminal reports a live TTY; update allTerminalTTYNames(),
applySessionPanelMetadata(), and surfaceTTYNames handling accordingly so
snapshot-restored TTYs are excluded until confirmed live.
- Around line 6361-6371: The panel restore command is being stored into
panelRestoreCommands without being validated (sessionPanelSnapshot writes that
dictionary verbatim and the reattach path bypasses setPanelRestoreCommand), so
update setPanelRestoreCommand to call
SessionRestoreCommandSettings.isCommandAllowed(trimmed) before storing and
reject/discard disallowed commands; also change the reattachment/reattach
codepath to route through the same helper (setPanelRestoreCommand) or the same
validation helper so all writes to panelRestoreCommands are consistently
gate-checked and use the same trimming/allowlist logic.
---
Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 3712-3713: The call to refreshForegroundProcessCacheSync() blocks
the main thread because saveSessionSnapshot remains on `@MainActor` while
SessionForegroundProcessCache.refresh(...) does a per-TTY ps loop; change the
flow so the snapshot save does not wait for that blocking refresh: either (A)
pre-run the refresh on a background queue and have
refreshForegroundProcessCacheSync() read and return the last completed cache
(consume cache snapshot instead of running refresh synchronously) or (B) move
the refresh+save pipeline off the main actor entirely by performing
SessionForegroundProcessCache.refresh(...) on a background Task/DispatchQueue
and then call saveSessionSnapshot only after that background work completes (or
call a non-@MainActor helper that collects data then dispatches the minimal
UI-bound work back to `@MainActor`). Update refreshForegroundProcessCacheSync,
SessionForegroundProcessCache.refresh, and saveSessionSnapshot call sites to use
the chosen approach so the UI is not blocked.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 47fb34d5-a7bd-41c8-9470-2003f199b637
📒 Files selected for processing (4)
Sources/AppDelegate.swiftSources/SessionPersistence.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- cmuxTests/SessionPersistenceTests.swift
|
@greptile-apps @cubic-dev-ai Please re-review - all flagged issues have been addressed in commits a81b133 and 80ed5c3. |
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
Sources/SessionPersistence.swift (1)
812-819: Consider extracting shell names to a static constant.The inline list works correctly, but extracting it as a static property would improve readability and make future additions easier to manage.
♻️ Optional refactor
enum SessionForegroundProcessDetector { private static let psPath = "/bin/ps" + + /// Shell executables to skip when detecting foreground commands. + private static let shellExecutables: Set<String> = [ + "zsh", "bash", "sh", "fish", "tcsh", "ksh", "dash", // POSIX-ish shells + "csh", // C shell + "pwsh", "powershell", // PowerShell + "nu", "nushell", // Nushell + "elvish", "xonsh", "oil", "osh", // Alternative shells + "rc", "es", // Plan 9 shells + ] // ... in detect(forTTY:) ... - let shellNames = [ - "zsh", "bash", "sh", "fish", "tcsh", "ksh", "dash", - // ... - ] - if shellNames.contains(foreground.executableName) { + if shellExecutables.contains(foreground.executableName) { return nil }Using a
Setalso gives O(1) lookup instead of O(n), though with ~15 elements the difference is negligible.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/SessionPersistence.swift` around lines 812 - 819, Extract the inline shellNames array into a shared static constant on the SessionPersistence type (e.g. static let knownShells: Set<String> = [...]) and replace the local let shellNames usage with SessionPersistence.knownShells (or Self.knownShells inside instance methods); convert the collection to a Set for O(1) lookup and keep the same elements (zsh, bash, sh, fish, tcsh, ksh, dash, csh, pwsh, powershell, nu, nushell, elvish, xonsh, oil, osh, rc, es) to preserve behavior and simplify future maintenance.Sources/Workspace.swift (1)
669-675: ReusesetPanelRestoreCommandhere too.This reimplements the same trim/allowlist policy that already lives in
setPanelRestoreCommand, so the restore path can drift from the socket and reattach paths.♻️ Suggested simplification
- // Persist explicit restoreCommand if allowed (blocked commands don't become sticky) - if let restoreCommand = snapshot.terminal?.restoreCommand, !restoreCommand.isEmpty { - let trimmed = restoreCommand.trimmingCharacters(in: .whitespacesAndNewlines) - if SessionRestoreCommandSettings.isCommandAllowed(trimmed) { - setPanelRestoreCommand(panelId: terminalPanel.id, command: trimmed) - } - } + // Persist explicit restoreCommand if allowed (blocked commands don't become sticky) + setPanelRestoreCommand(panelId: terminalPanel.id, command: snapshot.terminal?.restoreCommand)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 669 - 675, The code duplicates the trim/allowlist logic already implemented in setPanelRestoreCommand; remove the manual trimming and SessionRestoreCommandSettings check and simply call setPanelRestoreCommand(panelId: terminalPanel.id, command: restoreCommand) when snapshot.terminal?.restoreCommand is non-nil/non-empty so the same normalization and allowlist enforcement is used consistently for restore, socket, and reattach paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/Workspace.swift`:
- Around line 455-464: When creating the SessionTerminalPanelSnapshot, avoid
persisting panelRestoreCommands for terminals backed by a remote startup
command: compute a safeRestore = (remoteTerminalStartupCommand() == nil) ?
panelRestoreCommands[panelId] : nil and pass that into
SessionTerminalPanelSnapshot's restoreCommand instead of
panelRestoreCommands[panelId], so remote-only overrides are not replayed
locally; update the terminalSnapshot construction (symbols: terminalSnapshot,
SessionTerminalPanelSnapshot, panelRestoreCommands,
remoteTerminalStartupCommand()) accordingly.
---
Nitpick comments:
In `@Sources/SessionPersistence.swift`:
- Around line 812-819: Extract the inline shellNames array into a shared static
constant on the SessionPersistence type (e.g. static let knownShells:
Set<String> = [...]) and replace the local let shellNames usage with
SessionPersistence.knownShells (or Self.knownShells inside instance methods);
convert the collection to a Set for O(1) lookup and keep the same elements (zsh,
bash, sh, fish, tcsh, ksh, dash, csh, pwsh, powershell, nu, nushell, elvish,
xonsh, oil, osh, rc, es) to preserve behavior and simplify future maintenance.
In `@Sources/Workspace.swift`:
- Around line 669-675: The code duplicates the trim/allowlist logic already
implemented in setPanelRestoreCommand; remove the manual trimming and
SessionRestoreCommandSettings check and simply call
setPanelRestoreCommand(panelId: terminalPanel.id, command: restoreCommand) when
snapshot.terminal?.restoreCommand is non-nil/non-empty so the same normalization
and allowlist enforcement is used consistently for restore, socket, and reattach
paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f060831e-99a6-4d0d-a624-1475a4a28cab
📒 Files selected for processing (3)
Sources/AppDelegate.swiftSources/SessionPersistence.swiftSources/Workspace.swift
✅ Files skipped from review due to trivial changes (1)
- Sources/AppDelegate.swift
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
Sources/Workspace.swift (1)
237-241: Skip remote-backed terminals from the cache refresh set.
sessionPanelSnapshot(...)now ignores cached foreground detections for remote-backed terminals, so collecting their TTYs here only adds extrapswork during the pre-snapshot refresh. Reusing the same remote-backed predicate here would trim unnecessary quit-time work.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 237 - 241, In allTerminalTTYNames(), filter out terminal panels that are remote-backed the same way sessionPanelSnapshot(...) does so we don't include their TTYs in surfaceTTYNames; update the compactMap closure (which currently checks "panel is TerminalPanel") to also check the existing remote-backed predicate (reuse the same method/property used by sessionPanelSnapshot, e.g. panel.isRemoteBacked or the project's remote-backed check) and return nil for remote-backed terminals so only local terminal TTYs from surfaceTTYNames[panelId] are collected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/Workspace.swift`:
- Around line 455-469: The snapshot captures listeningPorts even for
remote-backed panels; reuse the existing remote-panel predicate
(isRemoteBackedTerminal computed from activeRemoteTerminalSurfaceIds and
transferredRemoteCleanupConfigurationsByPanelId) and ensure
SessionTerminalPanelSnapshot is created with listeningPorts set to [] when
isRemoteBackedTerminal is true (analogous to how restoreCommand and
detectedCommand are gated), and update applySessionPanelMetadata to ignore/skip
restoring any listeningPorts for panels that are remote-backed so
remote-detected ports are never persisted or rehydrated.
- Around line 649-680: The code persists an incoming
snapshot.terminal?.restoreCommand into panel restore state even for
SSH/remote-backed terminals; avoid making remote commands sticky. Change the
final block (the guard that calls setPanelRestoreCommand) to only persist the
trimmed restoreCommand when it's allowed AND the panel/terminal is local (e.g.,
check a local/remote indicator exposed by the created terminalPanel or
snapshot.terminal such as terminalPanel.isRemote/terminalPanel.connectionType or
snapshot.terminal?.isRemote/?.backend != .ssh) so that
setPanelRestoreCommand(panelId: terminalPanel.id, command: trimmed) is skipped
for remote-backed terminals.
---
Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 237-241: In allTerminalTTYNames(), filter out terminal panels that
are remote-backed the same way sessionPanelSnapshot(...) does so we don't
include their TTYs in surfaceTTYNames; update the compactMap closure (which
currently checks "panel is TerminalPanel") to also check the existing
remote-backed predicate (reuse the same method/property used by
sessionPanelSnapshot, e.g. panel.isRemoteBacked or the project's remote-backed
check) and return nil for remote-backed terminals so only local terminal TTYs
from surfaceTTYNames[panelId] are collected.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
|
@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - latest commit 1561fd8 addresses all remaining feedback:
|
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
|
🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
1 issue found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/SessionPersistence.swift">
<violation number="1" location="Sources/SessionPersistence.swift:935">
P1: argv parsing undercounts non-UTF8 entries, so command extraction can scan beyond argv and persist unintended process-args data.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
Sources/Workspace.swift (1)
749-753:⚠️ Potential issue | 🟠 MajorSkip restoring
listeningPortsfor remote-backed panels too.Line 751 correctly stops rehydrating dead-session TTYs, but Line 749 still restores
snapshot.listeningPortsunconditionally. Please reuse the same remote-backed predicate here; otherwise a legacy or tampered snapshot can repopulate remote-only ports after restore.🛡️ Suggested fix
- surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + let isRemoteBackedTerminal = + activeRemoteTerminalSurfaceIds.contains(panelId) || + transferredRemoteCleanupConfigurationsByPanelId[panelId] != nil + surfaceListeningPorts[panelId] = isRemoteBackedTerminal + ? [] + : Array(Set(snapshot.listeningPorts)).sorted()Based on learnings: remote-detected listening ports must never be persisted, and
applySessionPanelMetadatamust not restore any remote listening ports during session rehydration.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 749 - 753, In applySessionPanelMetadata, don't unconditionally restore snapshot.listeningPorts into surfaceListeningPorts; wrap the assignment of surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() in the same "remote-backed" check used to skip restoring ttyName so that remote-backed panels never get listeningPorts reapplied from a snapshot—only restore snapshot.listeningPorts for non-remote-backed panels.Sources/SessionPersistence.swift (1)
754-770:⚠️ Potential issue | 🟠 Major
refreshSyncstill blocks the quit path.
Sources/AppDelegate.swiftcalls the synchronous variant fromapplicationShouldTerminate(...)before saving the session. That means quit still waits on everyps+sysctlpass here, so shutdown can stall under load or with many TTYs. I’d avoid the sync refresh on termination and fall back to the last async cache snapshot, or add a bounded timeout/fallback.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/SessionPersistence.swift` around lines 754 - 770, The synchronous refreshSync(ttyNames:) currently blocks shutdown; change the termination path to avoid calling it: in applicationShouldTerminate(...) stop invoking refreshSync and instead read the most recent async snapshot from cache (protected by lock) or implement a bounded-sync variant that dispatches the same work onto queue asynchronously and waits with a timeout (e.g. DispatchSemaphore/DispatchGroup wait(timeout:)), then falls back to the existing cache if the wait times out. Update references to queue, cache, and refreshSync to use the new non-blocking fallback so quitting does not stall on long ps/sysctl calls.
🧹 Nitpick comments (2)
Sources/AppDelegate.swift (1)
3994-4005: Deduplicate TTY names before refresh to avoid redundant scans.Line 3994 and Line 4002 can pass duplicate TTYs if contexts overlap; deduping reduces unnecessary
pswork with no behavior change.♻️ Proposed refactor
private func refreshForegroundProcessCacheAsync() { - let allTTYNames = mainWindowContexts.values.flatMap { context in + let allTTYNames = Set(mainWindowContexts.values.flatMap { context in context.tabManager.allTerminalTTYNames() - } - SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames)) + }) + SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames)) } private func refreshForegroundProcessCacheSync() { - let allTTYNames = mainWindowContexts.values.flatMap { context in + let allTTYNames = Set(mainWindowContexts.values.flatMap { context in context.tabManager.allTerminalTTYNames() - } - SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames)) + }) + SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames)) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 3994 - 4005, The code gathers TTY names with mainWindowContexts.values.flatMap { context in context.tabManager.allTerminalTTYNames() } and then calls SessionForegroundProcessCache.shared.refresh(...) and refreshSync(...); deduplicate the collected TTY names before calling refresh/refreshSync (e.g., convert to a Set then back to Array) so duplicate TTYs from overlapping contexts are removed and redundant ps scanning is avoided. Ensure you update both call sites that use allTTYNames and keep the same types expected by refresh and refreshSync.cmuxTests/SessionPersistenceTests.swift (1)
1219-1305: Consider table-driven denylist cases to reduce repetition.Most denylist tests follow the same arrange/assert shape; a parameterized helper would keep the same coverage with lower maintenance overhead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1219 - 1305, Refactor the repetitive denylist tests by introducing a table-driven helper (e.g., a private helper method in the test file) that calls SessionRestoreCommandSettings.isCommandAllowed for a given rawAllowlist and an array of commands and asserts they are all false; replace repeated patterns in testDenylistBlocksDestructiveCommandsEvenIfInAllowlist, testDenylistIsCaseInsensitive, testDenylistBlocksExactCommands, testDenylistBlocksSystemCommands, testDenylistBlocksRemoteCodeExecution, testDenylistBlocksHistoryReplay, testDenylistBlocksCrontabDestruction, testDenylistBlocksMacOSSystemIntegrity, testDenylistBlocksContainerMassDestruction, testDenylistBlocksNetworkDestruction, and testDenylistBlocksLaunchctlDestruction with calls to that helper to keep coverage but reduce duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3158-3159: The current assignment of self.initialInput from
trimmedInput allows embedded newlines (e.g. "ssh host\nrm -rf ~"); update the
logic that sets initialInput (the trimmedInput → self.initialInput block) to
reject any input containing newline characters (check for "\n" and "\r") and set
self.initialInput = nil if found, rather than accepting or trimming them; apply
the same newline-rejection guard before any code that restores or submits
initialInput (the restore path around the code near lines 3885–3887) so
multi-line inputs are never accepted or re-submitted.
In `@Sources/SessionPersistence.swift`:
- Around line 668-670: The isCommandAllowed(_ command: String, defaults:
UserDefaults = .standard) helper currently ignores the user toggle; modify it to
first read the sessionRestoreCommandsEnabled Bool from defaults and immediately
return false when that flag is false, otherwise proceed to call
isCommandAllowed(command, rawAllowlist: defaults.string(forKey: allowlistKey));
reference the symbol sessionRestoreCommandsEnabled and the existing overload
isCommandAllowed(command, rawAllowlist:) so the gating happens before any
allowlist checks or writing to session JSON.
---
Duplicate comments:
In `@Sources/SessionPersistence.swift`:
- Around line 754-770: The synchronous refreshSync(ttyNames:) currently blocks
shutdown; change the termination path to avoid calling it: in
applicationShouldTerminate(...) stop invoking refreshSync and instead read the
most recent async snapshot from cache (protected by lock) or implement a
bounded-sync variant that dispatches the same work onto queue asynchronously and
waits with a timeout (e.g. DispatchSemaphore/DispatchGroup wait(timeout:)), then
falls back to the existing cache if the wait times out. Update references to
queue, cache, and refreshSync to use the new non-blocking fallback so quitting
does not stall on long ps/sysctl calls.
In `@Sources/Workspace.swift`:
- Around line 749-753: In applySessionPanelMetadata, don't unconditionally
restore snapshot.listeningPorts into surfaceListeningPorts; wrap the assignment
of surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted()
in the same "remote-backed" check used to skip restoring ttyName so that
remote-backed panels never get listeningPorts reapplied from a snapshot—only
restore snapshot.listeningPorts for non-remote-backed panels.
---
Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1219-1305: Refactor the repetitive denylist tests by introducing a
table-driven helper (e.g., a private helper method in the test file) that calls
SessionRestoreCommandSettings.isCommandAllowed for a given rawAllowlist and an
array of commands and asserts they are all false; replace repeated patterns in
testDenylistBlocksDestructiveCommandsEvenIfInAllowlist,
testDenylistIsCaseInsensitive, testDenylistBlocksExactCommands,
testDenylistBlocksSystemCommands, testDenylistBlocksRemoteCodeExecution,
testDenylistBlocksHistoryReplay, testDenylistBlocksCrontabDestruction,
testDenylistBlocksMacOSSystemIntegrity,
testDenylistBlocksContainerMassDestruction,
testDenylistBlocksNetworkDestruction, and testDenylistBlocksLaunchctlDestruction
with calls to that helper to keep coverage but reduce duplication.
In `@Sources/AppDelegate.swift`:
- Around line 3994-4005: The code gathers TTY names with
mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames() } and then calls
SessionForegroundProcessCache.shared.refresh(...) and refreshSync(...);
deduplicate the collected TTY names before calling refresh/refreshSync (e.g.,
convert to a Set then back to Array) so duplicate TTYs from overlapping contexts
are removed and redundant ps scanning is avoided. Ensure you update both call
sites that use allTTYNames and keep the same types expected by refresh and
refreshSync.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 223c37bb-42e4-4b1e-bff4-e6527c8e7435
📒 Files selected for processing (7)
Sources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/Panels/TerminalPanel.swiftSources/SessionPersistence.swiftSources/TabManager.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
|
@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - commit ac5665d addresses all feedback from the previous review: Actionable fixes:
Nitpick fixes:
All prior duplicate comments about remote-backed panels and refreshSync blocking have been addressed. |
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
|
Actionable fixes confirmed:
Nitpick fixes confirmed:
Starting the full review now. 🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
1 issue found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/SessionPersistence.swift">
<violation number="1" location="Sources/SessionPersistence.swift:229">
P2: Allowlisted commands are persisted with full arguments (including potential tokens/credentials), so session JSON can store sensitive CLI args unredacted.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
🧹 Nitpick comments (3)
Sources/AppDelegate.swift (1)
3712-3717: The non-terminating path still writes the previous cache state.Line 3716 kicks off a fire-and-forget refresh, but
buildSessionSnapshot(...)runs immediately after this block, so non-terminating saves still serialize whatever was already inSessionForegroundProcessCache. If that one-save lag is intentional, I'd make it explicit in the helper/comment or move the async refresh after snapshot construction so the control flow matches the behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 3712 - 3717, The current fire-and-forget call to refreshForegroundProcessCacheAsync() runs before buildSessionSnapshot(...), causing non-terminating saves to serialize the previous SessionForegroundProcessCache; either change the non-terminating path to call refreshForegroundProcessCacheSync() so the cache is up-to-date before calling buildSessionSnapshot(...), or move the refreshForegroundProcessCacheAsync() call to after buildSessionSnapshot(...) and add a comment explaining the intentional one-save lag, so control flow and behavior for isTerminatingApp, refreshForegroundProcessCacheSync(), refreshForegroundProcessCacheAsync(), buildSessionSnapshot(...), and SessionForegroundProcessCache are consistent and clear.cmuxTests/SessionPersistenceTests.swift (1)
1096-1322: Consider adding test coverage for the enabled toggle.The tests thoroughly cover allowlist/denylist pattern matching via
isCommandAllowed(_:rawAllowlist:), but there's no test verifying thatisCommandAllowed(_:defaults:)returnsfalsewhensessionRestoreCommandsEnabledis set tofalsein UserDefaults.This gap means the fix from commit ac5665d (respecting the enabled toggle) isn't covered by unit tests.
📝 Suggested test addition
func testDisabledSettingBlocksAllCommands() { let defaults = UserDefaults(suiteName: "SessionRestoreCommandSettingsTests")! defaults.removePersistentDomain(forName: "SessionRestoreCommandSettingsTests") // Explicitly disable restore commands defaults.set(false, forKey: SessionRestoreCommandSettings.enabledKey) // Even allowlisted commands should be blocked when disabled XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults)) XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("npm run dev", defaults: defaults)) } func testEnabledByDefaultWhenKeyNotSet() { let defaults = UserDefaults(suiteName: "SessionRestoreCommandSettingsTests")! defaults.removePersistentDomain(forName: "SessionRestoreCommandSettingsTests") // Key not set should default to enabled XCTAssertTrue(SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults)) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1096 - 1322, Add unit tests to cover the enabled toggle: create tests that set a custom UserDefaults suite, set SessionRestoreCommandSettings.enabledKey to false and assert SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults) and isCommandAllowed("npm run dev", defaults: defaults) return false; also add a test where the key is absent (removePersistentDomain) and assert isCommandAllowed("opencode", defaults: defaults) returns true to ensure default-enabled behavior. Reference SessionRestoreCommandSettings.isCommandAllowed(_:defaults:) and SessionRestoreCommandSettings.enabledKey when adding these tests.Sources/Workspace.swift (1)
653-691: Consider centralizing restore-command normalization.Trim + allowlist validation now lives in the restore-selection closure, the post-restore persistence block, and
setPanelRestoreCommand. A small helper would keep execute/persist/reattach behavior from drifting the next time the safety rules change.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 653 - 691, The restore-command trimming and allowlist check logic is duplicated across the restore selection closure, the persistence block, and setPanelRestoreCommand; create a single helper (e.g., normalizeAndValidateRestoreCommand(_:)->String?) that trims input, returns nil for empty strings, and returns the trimmed value only if SessionRestoreCommandSettings.isCommandAllowed(...) and use it everywhere: replace the inline trimming/validation in the closure where commandToRestore is computed, the post-restore persistence block that currently trims/passes to setPanelRestoreCommand, and update setPanelRestoreCommand to accept already-normalized input or call the helper internally so all execute/persist/reattach code paths share the same normalization/validation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1096-1322: Add unit tests to cover the enabled toggle: create
tests that set a custom UserDefaults suite, set
SessionRestoreCommandSettings.enabledKey to false and assert
SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults)
and isCommandAllowed("npm run dev", defaults: defaults) return false; also add a
test where the key is absent (removePersistentDomain) and assert
isCommandAllowed("opencode", defaults: defaults) returns true to ensure
default-enabled behavior. Reference
SessionRestoreCommandSettings.isCommandAllowed(_:defaults:) and
SessionRestoreCommandSettings.enabledKey when adding these tests.
In `@Sources/AppDelegate.swift`:
- Around line 3712-3717: The current fire-and-forget call to
refreshForegroundProcessCacheAsync() runs before buildSessionSnapshot(...),
causing non-terminating saves to serialize the previous
SessionForegroundProcessCache; either change the non-terminating path to call
refreshForegroundProcessCacheSync() so the cache is up-to-date before calling
buildSessionSnapshot(...), or move the refreshForegroundProcessCacheAsync() call
to after buildSessionSnapshot(...) and add a comment explaining the intentional
one-save lag, so control flow and behavior for isTerminatingApp,
refreshForegroundProcessCacheSync(), refreshForegroundProcessCacheAsync(),
buildSessionSnapshot(...), and SessionForegroundProcessCache are consistent and
clear.
In `@Sources/Workspace.swift`:
- Around line 653-691: The restore-command trimming and allowlist check logic is
duplicated across the restore selection closure, the persistence block, and
setPanelRestoreCommand; create a single helper (e.g.,
normalizeAndValidateRestoreCommand(_:)->String?) that trims input, returns nil
for empty strings, and returns the trimmed value only if
SessionRestoreCommandSettings.isCommandAllowed(...) and use it everywhere:
replace the inline trimming/validation in the closure where commandToRestore is
computed, the post-restore persistence block that currently trims/passes to
setPanelRestoreCommand, and update setPanelRestoreCommand to accept
already-normalized input or call the helper internally so all
execute/persist/reattach code paths share the same normalization/validation
behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 15c69e06-3b26-498b-8292-bf33958d31c9
📒 Files selected for processing (7)
Sources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/Panels/TerminalPanel.swiftSources/SessionPersistence.swiftSources/TabManager.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
|
@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - commit cccf205 addresses all remaining feedback: Security (Cubic P2):
CodeRabbit nitpicks:
|
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
|
Security (credential denylist):
CodeRabbit nitpicks:
Starting the full review now. 🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 7
♻️ Duplicate comments (1)
Sources/GhosttyTerminalView.swift (1)
3158-3164:⚠️ Potential issue | 🟠 MajorValidate the template
initialInputfallback too.The explicit path is sanitized here, but
resolvedInitialInputstill forwardsbaseConfig.initialInputunchanged. Becauseinitial_inputis also rehydrated from native surface config inSources/Workspace.swift:29-31, a newline-bearing template value can bypass this guard and still reachsurfaceConfig.initial_input. Please apply the same empty/CRLF normalization to the fallback path, or funnel both paths through one helper.Also applies to: 3888-3895
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/GhosttyTerminalView.swift` around lines 3158 - 3164, The resolvedInitialInput fallback path is not normalized so newline-bearing templates from baseConfig.initialInput or surfaceConfig.initial_input can bypass the CR/LF check; update resolvedInitialInput to trim whitespace/newlines and reject empty or CR/LF-containing strings (same logic used where initialInput is set) or refactor both assignments into a shared sanitizer helper (e.g., sanitizeInitialInput(_:)) and call it from places that set initialInput or resolvedInitialInput, referencing initialInput, resolvedInitialInput, baseConfig.initialInput and surfaceConfig.initial_input to locate where to apply the change.
🧹 Nitpick comments (2)
Sources/AppDelegate.swift (1)
3995-4011: Minor: factor out the shared TTY collection helper.Both methods duplicate the same
mainWindowContextswalk and dedupe logic. A tiny helper keeps future filtering changes aligned between the async and sync paths.♻️ Suggested refactor
+ private func allSessionTerminalTTYNames() -> [String] { + Array(Set(mainWindowContexts.values.flatMap { context in + context.tabManager.allTerminalTTYNames() + })) + } + /// Refresh the foreground process cache for all terminal panels (async, non-blocking). private func refreshForegroundProcessCacheAsync() { - // Dedupe TTY names to avoid redundant ps scans - let allTTYNames = Set(mainWindowContexts.values.flatMap { context in - context.tabManager.allTerminalTTYNames() - }) - SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames)) + SessionForegroundProcessCache.shared.refresh(ttyNames: allSessionTerminalTTYNames()) } /// Refresh the foreground process cache for all terminal panels (blocking, for quit). private func refreshForegroundProcessCacheSync() { - // Dedupe TTY names to avoid redundant ps scans - let allTTYNames = Set(mainWindowContexts.values.flatMap { context in - context.tabManager.allTerminalTTYNames() - }) - SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames)) + SessionForegroundProcessCache.shared.refreshSync(ttyNames: allSessionTerminalTTYNames()) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 3995 - 4011, Extract the duplicated walk-and-dedupe logic into a small private helper (e.g. private func allTerminalTTYNames() -> [String]) that builds the Set from mainWindowContexts.values.flatMap { $0.tabManager.allTerminalTTYNames() } and returns an Array of the deduped names; then update refreshForegroundProcessCacheAsync() to call SessionForegroundProcessCache.shared.refresh(ttyNames: allTerminalTTYNames()) and refreshForegroundProcessCacheSync() to call SessionForegroundProcessCache.shared.refreshSync(ttyNames: allTerminalTTYNames()) so both paths share the same collection logic.cmuxTests/SessionPersistenceTests.swift (1)
1096-1394: Add one persistence-path regression test.Everything here exercises
SessionRestoreCommandSettingsdirectly, but nothing asserts that a blockeddetectedCommand/restoreCommandis actually omitted from the serialized session snapshot. That is the high-value boundary for this feature, and a regression there would still leave this suite green.Based on learnings: "unallowed (potentially sensitive) commands are never written to the session JSON."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1096 - 1394, Add a regression test that verifies blocked commands are never written to the serialized session snapshot: create a session (using the same test harness in SessionPersistenceTests.swift), add an entry whose detectedCommand/restoreCommand would be blocked by SessionRestoreCommandSettings.isCommandAllowed (e.g., "rm -rf /"), run the session serialization/path that produces the session JSON via the existing persistence/serializer code path, and assert the produced JSON/string does not contain that command and that the corresponding entry is omitted; reference SessionRestoreCommandSettings.isCommandAllowed and the detectedCommand/restoreCommand fields to locate the right places to construct the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1351-1377: The test currently sets allowlist = "opencode *\nnpm
*\nmysql *\naws *" so many samples (e.g., "mycli --bearer=...", "psql
--passwd=...", "mycli mongodb://...") get rejected by the allowlist before the
denylist is exercised; change the test in testDenylistBlocksSensitiveCredentials
so the allowlist does not match those samples (for example set allowlist = "" or
remove/clear the allowlist argument) so that assertAllBlocked([...], allowlist:
allowlist) actually exercises the denylist rules for the listed credential
cases.
- Around line 1357-1369: The test fixtures in SessionPersistenceTests currently
include secret-shaped literals (e.g., "opencode --api-key=sk-1234567890",
"AKIA...") which trip secret scanners; update the fixture array to avoid
checked-in secrets by either constructing fake tokens from harmless fragments at
runtime (e.g., join("sk-", "XXXX") or concatenating ["AKIA","_REDACTED"]) or
replace them with clearly redacted placeholders like "sk-REDACTED" and
"AWS_ACCESS_KEY_ID=AKIA_REDACTED" so the matcher behavior is preserved but no
real-looking secrets are committed; change the strings in the given fixture list
inside SessionPersistenceTests (the array with "opencode --api-key=...", "mycli
--bearer=...", "aws --aws-access-key-id=...") accordingly.
In `@Sources/AppDelegate.swift`:
- Around line 3712-3720: The synchronous foreground cache refresh is currently
keyed off isTerminatingApp and runs when the quit confirmation is shown; change
it so the synchronous refresh only happens on the actual confirmed termination
path (e.g. in applicationWillTerminate(_:)) instead of when isTerminatingApp is
set prior to the prompt. Concretely, revert the branch around
refreshForegroundProcessCacheSync()/refreshForegroundProcessCacheAsync() so
saveSessionSnapshot(...) uses the async refresh during the quit-confirmation
flow, and move or gate the call to refreshForegroundProcessCacheSync() to the
confirmed termination handler (applicationWillTerminate(_:)) or a separate
isConfirmedTermination flag; update references to isTerminatingApp,
refreshForegroundProcessCacheSync(), refreshForegroundProcessCacheAsync(),
saveSessionSnapshot(...), and applicationWillTerminate(_:) accordingly.
In `@Sources/SessionPersistence.swift`:
- Around line 1026-1031: The persisted restore string currently collapses
argv[0] to its basename (execName = URL(fileURLWithPath:
args[0]).lastPathComponent), which loses path-qualified executables; change it
to keep the original argv[0] (use args[0] as the executable token) and continue
to shell-quote the remaining args (shellQuoteIfNeeded), joining them as before,
so the returned string uses the full original executable token plus quoted
arguments; perform any basename normalization later in the matcher code instead
of here.
- Around line 689-690: Remove the bare "-p " and "-P " entries from the global
denylist array and instead implement command-specific checks: in the function
that decides persistence (e.g., the method that evaluates denylist tokens and
command strings, such as isCommandPersistable / shouldPersistCommand), detect
the actual command name (lowercased) and only treat "-p" as a sensitive password
flag when the command is one that uses that short flag (e.g., "mysql",
"mysqldump", "psql", etc. — add whatever concrete binaries your project intends
to block); perform a case-insensitive search for the short flag in those
command-specific branches and deny persistence for those matches. Ensure you
remove the generic "-p " / "-P " tokens so safe uses like "cargo run -p" or
"flask run -p" are not globally blocked.
In `@Sources/Workspace.swift`:
- Around line 8580-8582: The code currently rehydrates detached.restoreCommand
by calling setPanelRestoreCommand(panelId: detached.panelId, command:
restoreCommand) too early; move that write so it occurs after remote-state setup
(after didAdoptWorkspaceRemoteTracking and
transferredRemoteCleanupConfigurationsByPanelId are applied) and guard it so you
do not set a restore command when detached.isRemoteTerminal is true; in
practice, relocate the setPanelRestoreCommand call below the logic that
re-establishes remote tracking/cleanup and add a check on
detached.isRemoteTerminal (and skip writing to panelRestoreCommands for remote
terminals).
- Around line 235-247: allTerminalTTYNames() and isRemoteBackedTerminal rely
only on activeRemoteTerminalSurfaceIds and
transferredRemoteCleanupConfigurationsByPanelId, but
markRemoteTerminalSessionEnded(...) and
cleanupTransferredRemoteConnectionIfNeeded(...) currently clear those without
clearing surfaceTTYNames or restore/detectedCommand metadata, letting stale
SSH-backed ttys be treated as local at snapshot time; update the
end-of-remote-backup cleanup path to either retain a transitional remote marker
for the panel until a new local tty is detected or explicitly clear remote-only
metadata (surfaceTTYNames[panelId], any stored detectedCommand/restoreCommand
overrides, and transferredRemoteCleanupConfigurationsByPanelId entries) when
remote backing ends so allTerminalTTYNames() and isRemoteBackedTerminal won't
include stale entries; adjust markRemoteTerminalSessionEnded(...) and
cleanupTransferredRemoteConnectionIfNeeded(...) to perform this clearing or
marker retention consistently.
---
Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3158-3164: The resolvedInitialInput fallback path is not
normalized so newline-bearing templates from baseConfig.initialInput or
surfaceConfig.initial_input can bypass the CR/LF check; update
resolvedInitialInput to trim whitespace/newlines and reject empty or
CR/LF-containing strings (same logic used where initialInput is set) or refactor
both assignments into a shared sanitizer helper (e.g., sanitizeInitialInput(_:))
and call it from places that set initialInput or resolvedInitialInput,
referencing initialInput, resolvedInitialInput, baseConfig.initialInput and
surfaceConfig.initial_input to locate where to apply the change.
---
Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1096-1394: Add a regression test that verifies blocked commands
are never written to the serialized session snapshot: create a session (using
the same test harness in SessionPersistenceTests.swift), add an entry whose
detectedCommand/restoreCommand would be blocked by
SessionRestoreCommandSettings.isCommandAllowed (e.g., "rm -rf /"), run the
session serialization/path that produces the session JSON via the existing
persistence/serializer code path, and assert the produced JSON/string does not
contain that command and that the corresponding entry is omitted; reference
SessionRestoreCommandSettings.isCommandAllowed and the
detectedCommand/restoreCommand fields to locate the right places to construct
the test.
In `@Sources/AppDelegate.swift`:
- Around line 3995-4011: Extract the duplicated walk-and-dedupe logic into a
small private helper (e.g. private func allTerminalTTYNames() -> [String]) that
builds the Set from mainWindowContexts.values.flatMap {
$0.tabManager.allTerminalTTYNames() } and returns an Array of the deduped names;
then update refreshForegroundProcessCacheAsync() to call
SessionForegroundProcessCache.shared.refresh(ttyNames: allTerminalTTYNames())
and refreshForegroundProcessCacheSync() to call
SessionForegroundProcessCache.shared.refreshSync(ttyNames:
allTerminalTTYNames()) so both paths share the same collection logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ea1faf08-76a2-40bd-aa06-9c76129f1311
📒 Files selected for processing (7)
Sources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/Panels/TerminalPanel.swiftSources/SessionPersistence.swiftSources/TabManager.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
Sources/Workspace.swift (1)
676-700:⚠️ Potential issue | 🟠 MajorUse
isRemoteBackedto choose the restored backend.Lines 695-700 still route every terminal restore through
newTerminalSurface(), and that helper always derivesinitialCommandfrom the workspace-wideremoteTerminalStartupCommand(). In a remote-configured workspace, a locally attached terminal will still be recreated as SSH-backed after restart; the per-panel flag currently only controls whether replay text is sent.🛠️ Suggested change
- guard let terminalPanel = newTerminalSurface( + let restoredInitialCommand = panelWasRemoteBacked ? remoteTerminalStartupCommand() : nil + guard let terminalPanel = newTerminalSurface( inPane: paneId, focus: false, workingDirectory: workingDirectory, startupEnvironment: replayEnvironment, + initialCommandOverride: restoredInitialCommand, initialInput: commandToRestore ) else {- func newTerminalSurface( + func newTerminalSurface( inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, startupEnvironment: [String: String] = [:], + initialCommandOverride: String? = nil, initialInput: String? = nil ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() - // Skip initialInput for SSH (restore text would land in prompts) - let safeInitialInput = remoteTerminalStartupCommand != nil ? nil : initialInput + let resolvedInitialCommand = initialCommandOverride ?? remoteTerminalStartupCommand() + // Skip initialInput for SSH (restore text would land in prompts) + let safeInitialInput = resolvedInitialCommand != nil ? nil : initialInput let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: workingDirectory, portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand, + initialCommand: resolvedInitialCommand, initialInput: safeInitialInput, additionalEnvironment: startupEnvironment )Based on learnings:
createPanel(from:inPane:)should use the per-panelisRemoteBackedflag so local terminals attached to remote-configured workspaces restore as local panels.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 676 - 700, The restore path currently ignores per-panel backend and always uses workspace-level remoteTerminalStartupCommand(), causing locally attached terminals to be recreated as SSH-backed; update createPanel(from:inPane:) (the code that calls newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:)) to check the panel's snapshot.terminal?.isRemoteBacked (or isRemoteBacked) and pass the correct backend decision into newTerminalSurface so that when isRemoteBacked == false you do not derive startup backend from remoteTerminalStartupCommand() and instead restore as a local terminal while still preserving initialInput (commandToRestore) behavior and allowlist validation via SessionRestoreCommandSettings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 676-700: The restore path currently ignores per-panel backend and
always uses workspace-level remoteTerminalStartupCommand(), causing locally
attached terminals to be recreated as SSH-backed; update
createPanel(from:inPane:) (the code that calls
newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:))
to check the panel's snapshot.terminal?.isRemoteBacked (or isRemoteBacked) and
pass the correct backend decision into newTerminalSurface so that when
isRemoteBacked == false you do not derive startup backend from
remoteTerminalStartupCommand() and instead restore as a local terminal while
still preserving initialInput (commandToRestore) behavior and allowlist
validation via SessionRestoreCommandSettings.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b00416e3-8278-40f0-90ec-6863c9d0c1b9
📒 Files selected for processing (3)
Sources/GhosttyTerminalView.swiftSources/SessionPersistence.swiftSources/Workspace.swift
- Set isTerminatingApp = true before every .terminateNow return and dialog confirmation (was only set in applicationWillTerminate) - Simplify verbose comments in Workspace.swift session snapshot/restore - Reduce debug logging in GhosttyTerminalView to single presence check - Move listeningPorts and isRemoteBackedTerminal outside switch statement
…d tests - Restore remoteDetectedSurfaceIds check for listeningPorts in snapshot (was accidentally removed when refactoring to isRemoteBackedForSessionSnapshot) - Add ssh/mosh to default allowlist for session restore - Add sshpass and :@ (user:pass@host) to denylist for security - Add comment pointing to primary validation in SessionRestoreCommandSettings - Restore comment explaining isTerminatingApp reset on quit cancel - Add tests for remote-backed terminal snapshot behavior - Add tests for SSH credential blocking and safe SSH commands
79008ae to
2cde7d4
Compare
|
@coderabbitai review |
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
|
🧠 Learnings used✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
2 issues found across 7 files
You’re at about 92% of the monthly review limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/SessionPersistence.swift">
<violation number="1" location="Sources/SessionPersistence.swift:806">
P2: Lowercasing the entire command before MySQL password detection makes the check treat MySQL's case-sensitive "-P" (port) flag as "-p" (password), so safe commands like `mysql -P 3306` are incorrectly denied and won't be restored.</violation>
</file>
<file name="Sources/Workspace.swift">
<violation number="1" location="Sources/Workspace.swift:8015">
P2: trackRemoteTerminalSurface now clears surfaceTTYNames even when a detached remote terminal has just restored a valid TTY, dropping the mapping used for remote port scans until a new TTY event arrives.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
♻️ Duplicate comments (4)
Sources/AppDelegate.swift (1)
2863-2885:⚠️ Potential issue | 🟠 MajorThe precautionary async save can still clobber the final termination snapshot.
This save is still queued before the quit path is resolved. If shutdown proceeds,
applicationWillTerminate(_:)does the fresh synchronous save, but the older async write can still finish afterward and overwrite it with stale foreground-process data. Guard async persistence once termination starts, or serialize both write paths through the same queue.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 2863 - 2885, The async precautionary save called in applicationShouldTerminate(_:) via saveSessionSnapshot(includeScrollback:removeWhenEmpty:) can complete after applicationWillTerminate(_:) performs a synchronous final save and thus overwrite it with stale data; fix by preventing that race: when entering the quit path set isTerminatingApp (or check that flag) before scheduling the async save and skip or cancel the async write if termination has started, or instead funnel both async and sync saves through a single serial persistence queue used by saveSessionSnapshot and applicationWillTerminate so the final synchronous save always runs after any pending async work; update applicationShouldTerminate(_:), saveSessionSnapshot(...), and applicationWillTerminate(_:) to use the shared queue or honor isTerminatingApp to avoid the clobber.cmuxTests/SessionPersistenceTests.swift (1)
1627-1668:⚠️ Potential issue | 🟠 MajorDenylist tests still have allowlist short-circuit cases.
On Line 1643 (
AWS_ACCESS_KEY_ID=... aws s3 ls) and Line 1660-1662 (sshpass ...), commands are not matched by the provided allowlists, so failures can come from allowlist rejection rather than denylist enforcement. That weakens these security regressions tests.✅ Suggested test hardening
func testDenylistBlocksSensitiveCredentials() { - // Commands with tokens/credentials anywhere should be blocked - let allowlist = "opencode *\nnpm *\naws *\nmycli *\npsql *\ngit *" - assertAllBlocked([ + let commands = [ // API keys and tokens "opencode --api-key=test-key-here", "opencode --token=test-token", "npm run dev --access-token=test-token", "mycli --bearer=test-bearer", "mycli --secret=test-secret", // Passwords "mycli --password=test-pass", "psql --passwd=test-pass", // AWS credentials "aws --aws-access-key-id=AKIAIOSFODNN7EXAMPLE", "aws --aws-secret-access-key=test-secret", "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE aws s3 ls", // Database connection strings "mycli mongodb://user:pass@host/db", "mycli postgresql://user:pass@host/db", // SSH keys "mycli --private-key=/path/to/key", // Sensitive file access "mycli cat .ssh/id_rsa", "mycli cat .aws/credentials", "mycli cat .kube/config", - ], allowlist: allowlist) + ] + let allowlist = commands.joined(separator: "\n") + assertAllBlocked(commands, allowlist: allowlist) } func testDenylistBlocksSSHWithCredentials() { - let allowlist = "ssh *\nmosh *" - assertAllBlocked([ + let commands = [ // sshpass password wrapper "sshpass -p secret ssh user@host", "sshpass -f /path/to/passfile ssh user@host", // user:pass@host syntax "ssh user:password@host", "mosh user:secret@server.com", // Default key paths "ssh -i ~/.ssh/id_rsa user@host", "ssh -i .ssh/id_ed25519 user@host", - ], allowlist: allowlist) + ] + let allowlist = commands.joined(separator: "\n") + assertAllBlocked(commands, allowlist: allowlist) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1627 - 1668, In testDenylistBlocksSensitiveCredentials and testDenylistBlocksSSHWithCredentials, some test cases (the "AWS_ACCESS_KEY_ID=... aws s3 ls" string in testDenylistBlocksSensitiveCredentials and the "sshpass ..." / "ssh user:password@host" entries in testDenylistBlocksSSHWithCredentials) can be short‑circuited by the allowlist; update the tests so the denylist is actually exercised: either remove or tighten the allowlist entries ("aws *", "ssh *", "mosh *") used in these two tests, or move those specific command strings into separate test cases whose allowlist intentionally does not match them; modify the allowlist strings referenced in these tests (the local variables allowlist) or split the test arrays in the functions testDenylistBlocksSensitiveCredentials and testDenylistBlocksSSHWithCredentials so the denylist logic is tested directly for the listed AWS and sshpass/username:password cases.Sources/Workspace.swift (2)
8011-8019:⚠️ Potential issue | 🟡 MinorDon't clear a moved remote tab's carried TTY here.
attachDetachedSurface(...)rehydratesdetached.ttyNamebefore remote tracking is re-adopted. This unconditional clear removes it again, so the panel falls out of TTY-scoped remote port scanning after the nextsyncRemotePortScanTTYs()until the shell reports a new TTY. Preserve a non-empty existing TTY here, or reapply the detached TTY aftertrackRemoteTerminalSurface().Based on learnings,
WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:)must dropremoteScannedPortsByPanelentries when a panel’s TTY changes, so clearing a carried TTY here causes moved remote panels to fall out of tracking on the next sync.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 8011 - 8019, The code in trackRemoteTerminalSurface(_) unconditionally clears surfaceTTYNames for the panelId (surfaceTTYNames.removeValue(forKey: panelId)), which wipes out a detached.ttyName rehydrated earlier in attachDetachedSurface(...) and causes the panel to drop out of TTY-scoped remote port scanning; change this so you do not remove an existing non-empty TTY: either skip the removeValue call when surfaceTTYNames[panelId] is non-empty or reapply detached.ttyName immediately after tracking is adopted (e.g. call applyPendingRemoteSurfaceTTYIfNeeded(to:) or restore the detached TTY before/after activeRemoteTerminalSurfaceIds.insert), and ensure updateRemotePortScanTTYsLocked(_:) will see the TTY change so remoteScannedPortsByPanel entries are updated correctly.
662-680:⚠️ Potential issue | 🟠 MajorPer-panel remote/local identity still isn't applied during restore.
panelWasRemoteBackedonly suppresses replay metadata. The actual constructor path still picksinitialCommandfrom the workspace-wideremoteTerminalStartupCommand(), so a restored workspace cannot faithfully recreate a mix of SSH-backed terminals and detached-in local terminals. That also lets Lines 678-680 repopulate a local restore override onto a panel that may have just been recreated as remote. Thread the per-panel backend flag into terminal creation instead of only using it to gateinitialInputandpanelRestoreCommands.Also applies to: 8695-8705
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 662 - 680, The restore path currently only uses panelWasRemoteBacked to gate initialInput but still lets the constructor pick a workspace-wide remoteTerminalStartupCommand(), so add per-panel backend information into terminal creation: modify the call to newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:) to accept and pass a per-panel isRemoteBacked flag (derived from snapshot.terminal?.isRemoteBacked as stored in panelWasRemoteBacked) and update newTerminalSurface/terminal construction logic to prefer per-panel behavior for choosing initialCommand (falling back to workspace remoteTerminalStartupCommand() only when per-panel isRemoteBacked is true or unset). Also change the post-creation restore-override logic (the setPanelRestoreCommand(panelId:command:) conditional) to check the actual terminalPanel.isRemoteBacked (not just panelWasRemoteBacked) so you don't apply local restore overrides to panels created as remote; apply the same change in the other affected block noted (around lines 8695-8705).
🧹 Nitpick comments (3)
Sources/AppDelegate.swift (1)
4026-4042: Extract the shared TTY-name collection.Both methods duplicate the same collection/dedupe pipeline, so future filtering or normalization changes can drift between the async and sync paths.
♻️ Small cleanup
+ private func foregroundProcessCacheTTYNames() -> [String] { + Array(Set(mainWindowContexts.values.flatMap { context in + context.tabManager.allTerminalTTYNames() + })) + } + /// Refresh the foreground process cache for all terminal panels (async, non-blocking). private func refreshForegroundProcessCacheAsync() { - // Dedupe TTY names to avoid redundant ps scans - let allTTYNames = Set(mainWindowContexts.values.flatMap { context in - context.tabManager.allTerminalTTYNames() - }) - SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames)) + SessionForegroundProcessCache.shared.refresh(ttyNames: foregroundProcessCacheTTYNames()) } /// Refresh the foreground process cache for all terminal panels (blocking, for quit). private func refreshForegroundProcessCacheSync() { - // Dedupe TTY names to avoid redundant ps scans - let allTTYNames = Set(mainWindowContexts.values.flatMap { context in - context.tabManager.allTerminalTTYNames() - }) - SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames)) + SessionForegroundProcessCache.shared.refreshSync(ttyNames: foregroundProcessCacheTTYNames()) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 4026 - 4042, Extract the duplicated TTY-name collection into a single helper (e.g., a private method like collectAllTTYNames() or allTTYNames()) that performs the Set dedupe over mainWindowContexts.values.flatMap { context in context.tabManager.allTerminalTTYNames() } and returns an Array of TTY names; then replace the duplicated code in refreshForegroundProcessCacheAsync() and refreshForegroundProcessCacheSync() so they each call the new helper and pass its result to SessionForegroundProcessCache.shared.refresh(...) and refreshSync(...), respectively. Ensure the helper is private and used only by these two methods so future normalization/filtering changes stay in one place.cmuxTests/SessionPersistenceTests.swift (1)
1203-1233: IsolateUserDefaultssuites per test to avoid shared-state coupling.Using one suite name (
"SessionRestoreCommandSettingsTests") across tests can make behavior order-dependent under parallel runs. Prefer a per-test suite name plus cleanup viadefer.Refactor sketch
+ private func makeIsolatedDefaults(file: StaticString = `#filePath`, line: UInt = `#line`) -> (UserDefaults, String) { + let suite = "SessionRestoreCommandSettingsTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return (defaults, suite) + } func testDisabledSettingBlocksAllCommands() { - let defaults = UserDefaults(suiteName: "SessionRestoreCommandSettingsTests")! - defaults.removePersistentDomain(forName: "SessionRestoreCommandSettingsTests") + let (defaults, suite) = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: suite) } defaults.set(false, forKey: SessionRestoreCommandSettings.enabledKey) ... }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1203 - 1233, Tests share the same UserDefaults suite "SessionRestoreCommandSettingsTests", causing inter-test coupling; update each test to create a unique suite (e.g., append UUID or test name when calling UserDefaults(suiteName:)), use that unique defaults when setting/getting SessionRestoreCommandSettings.enabledKey and SessionRestoreCommandSettings.allowlistKey, and ensure cleanup by removing the persistent domain in a defer block (defaults.removePersistentDomain(forName: ...)) so SessionRestoreCommandSettings.isCommandAllowed uses an isolated UserDefaults instance per test.Sources/SessionPersistence.swift (1)
1141-1147: Minor: DuplicatednormalizeTTYNameimplementation.The TTY normalization logic is duplicated between
SessionForegroundProcessCache.normalizeTTYNameandSessionForegroundProcessDetector.normalizeTTYName. Consider extracting to a shared utility, though the current duplication is acceptable for keeping the classes independent.Also applies to: 1073-1079
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/SessionPersistence.swift` around lines 1141 - 1147, Duplicate TTY normalization exists in SessionForegroundProcessCache.normalizeTTYName and SessionForegroundProcessDetector.normalizeTTYName; extract the trimming-and-/dev/ stripping logic into a single shared helper (for example a free function normalizeTTYName(_: String) or a String extension method) and replace both class-private implementations to call that shared helper so the logic is centralized and the duplicate methods removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1627-1668: In testDenylistBlocksSensitiveCredentials and
testDenylistBlocksSSHWithCredentials, some test cases (the
"AWS_ACCESS_KEY_ID=... aws s3 ls" string in
testDenylistBlocksSensitiveCredentials and the "sshpass ..." / "ssh
user:password@host" entries in testDenylistBlocksSSHWithCredentials) can be
short‑circuited by the allowlist; update the tests so the denylist is actually
exercised: either remove or tighten the allowlist entries ("aws *", "ssh *",
"mosh *") used in these two tests, or move those specific command strings into
separate test cases whose allowlist intentionally does not match them; modify
the allowlist strings referenced in these tests (the local variables allowlist)
or split the test arrays in the functions testDenylistBlocksSensitiveCredentials
and testDenylistBlocksSSHWithCredentials so the denylist logic is tested
directly for the listed AWS and sshpass/username:password cases.
In `@Sources/AppDelegate.swift`:
- Around line 2863-2885: The async precautionary save called in
applicationShouldTerminate(_:) via
saveSessionSnapshot(includeScrollback:removeWhenEmpty:) can complete after
applicationWillTerminate(_:) performs a synchronous final save and thus
overwrite it with stale data; fix by preventing that race: when entering the
quit path set isTerminatingApp (or check that flag) before scheduling the async
save and skip or cancel the async write if termination has started, or instead
funnel both async and sync saves through a single serial persistence queue used
by saveSessionSnapshot and applicationWillTerminate so the final synchronous
save always runs after any pending async work; update
applicationShouldTerminate(_:), saveSessionSnapshot(...), and
applicationWillTerminate(_:) to use the shared queue or honor isTerminatingApp
to avoid the clobber.
In `@Sources/Workspace.swift`:
- Around line 8011-8019: The code in trackRemoteTerminalSurface(_)
unconditionally clears surfaceTTYNames for the panelId
(surfaceTTYNames.removeValue(forKey: panelId)), which wipes out a
detached.ttyName rehydrated earlier in attachDetachedSurface(...) and causes the
panel to drop out of TTY-scoped remote port scanning; change this so you do not
remove an existing non-empty TTY: either skip the removeValue call when
surfaceTTYNames[panelId] is non-empty or reapply detached.ttyName immediately
after tracking is adopted (e.g. call applyPendingRemoteSurfaceTTYIfNeeded(to:)
or restore the detached TTY before/after activeRemoteTerminalSurfaceIds.insert),
and ensure updateRemotePortScanTTYsLocked(_:) will see the TTY change so
remoteScannedPortsByPanel entries are updated correctly.
- Around line 662-680: The restore path currently only uses panelWasRemoteBacked
to gate initialInput but still lets the constructor pick a workspace-wide
remoteTerminalStartupCommand(), so add per-panel backend information into
terminal creation: modify the call to
newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:)
to accept and pass a per-panel isRemoteBacked flag (derived from
snapshot.terminal?.isRemoteBacked as stored in panelWasRemoteBacked) and update
newTerminalSurface/terminal construction logic to prefer per-panel behavior for
choosing initialCommand (falling back to workspace
remoteTerminalStartupCommand() only when per-panel isRemoteBacked is true or
unset). Also change the post-creation restore-override logic (the
setPanelRestoreCommand(panelId:command:) conditional) to check the actual
terminalPanel.isRemoteBacked (not just panelWasRemoteBacked) so you don't apply
local restore overrides to panels created as remote; apply the same change in
the other affected block noted (around lines 8695-8705).
---
Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1203-1233: Tests share the same UserDefaults suite
"SessionRestoreCommandSettingsTests", causing inter-test coupling; update each
test to create a unique suite (e.g., append UUID or test name when calling
UserDefaults(suiteName:)), use that unique defaults when setting/getting
SessionRestoreCommandSettings.enabledKey and
SessionRestoreCommandSettings.allowlistKey, and ensure cleanup by removing the
persistent domain in a defer block (defaults.removePersistentDomain(forName:
...)) so SessionRestoreCommandSettings.isCommandAllowed uses an isolated
UserDefaults instance per test.
In `@Sources/AppDelegate.swift`:
- Around line 4026-4042: Extract the duplicated TTY-name collection into a
single helper (e.g., a private method like collectAllTTYNames() or
allTTYNames()) that performs the Set dedupe over
mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames() } and returns an Array of TTY names;
then replace the duplicated code in refreshForegroundProcessCacheAsync() and
refreshForegroundProcessCacheSync() so they each call the new helper and pass
its result to SessionForegroundProcessCache.shared.refresh(...) and
refreshSync(...), respectively. Ensure the helper is private and used only by
these two methods so future normalization/filtering changes stay in one place.
In `@Sources/SessionPersistence.swift`:
- Around line 1141-1147: Duplicate TTY normalization exists in
SessionForegroundProcessCache.normalizeTTYName and
SessionForegroundProcessDetector.normalizeTTYName; extract the
trimming-and-/dev/ stripping logic into a single shared helper (for example a
free function normalizeTTYName(_: String) or a String extension method) and
replace both class-private implementations to call that shared helper so the
logic is centralized and the duplicate methods removed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 084867ac-1b52-42ba-be7d-834beb1991ed
📒 Files selected for processing (5)
Sources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/SessionPersistence.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
- Fix MySQL -P (port) vs -p (password) case sensitivity: check -p flag on original command instead of lowercased version to preserve case distinction - Fix TTY clearing on detach/reattach: move surfaceTTYNames clearing from trackRemoteTerminalSurface() to seedInitialRemoteTerminalSessionIfNeeded() so detached remote terminals keep their valid TTYs
|
@coderabbitai review |
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
|
🧠 Learnings used✅ Actions performedReview triggered.
|
|
This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev. |
Remove the unused restoreCommand/panelRestoreCommands infrastructure: - Remove restoreCommand field from SessionTerminalPanelSnapshot - Remove panelRestoreCommands dictionary from Workspace - Remove setPanelRestoreCommand method - Remove restoreCommand from DetachedSurfaceTransfer - Simplify restore logic to only use detectedCommand The restoreCommand field was reserved for a future socket API but was never populated. All command restoration now uses auto-detected foreground process commands (detectedCommand) which is the actual implemented behavior.
530dc66 to
453b519
Compare
|
@coderabbitai review |
@mrosnerr I have started the AI code review. It will take a few minutes to complete. |
|
🧠 Learnings used✅ Actions performedReview triggered.
|
- Remove SessionForegroundProcessCache.clear() (not called anywhere) - Remove SessionRestoreCommandSettings.defaultAllowlistText (intended for Settings UI in part 2)
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
Sources/SessionPersistence.swift (1)
1236-1280:⚠️ Potential issue | 🟠 MajorPreserve empty
argventries instead of eliding them.
Line 1252counts empty arguments but still drops them fromargs, sotool "" --flagis persisted astool --flag. That silently restores a different command.🧩 Suggested fix
var args: [String] = [] @@ if byte == 0 { // Always count this as an argument, even if empty argCount += 1 if i > start { let slice = Array(buffer[start..<i]) guard let s = String(bytes: slice, encoding: .utf8) else { // Abort on first UTF-8 decode failure return nil } args.append(s) + } else { + args.append("") } - // Empty strings (i == start) are counted but not added to args if argCount >= Int(argc) { break } start = i + 1 } } @@ private static func shellQuoteIfNeeded(_ s: String) -> String { + if s.isEmpty { return "''" } // Characters that require quoting in shell let needsQuoting = s.contains { c in🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/SessionPersistence.swift` around lines 1236 - 1280, The loop parsing argv currently increments argCount for empty NUL-separated entries but omits adding empty strings to args, so sequences like tool "" --flag become tool --flag; modify the parsing loop (the block that uses variables buffer, offset, argc, args, argCount, start) to append an empty string to args when i == start (i.e., when a zero byte terminates an empty argument) instead of skipping it; keep the existing UTF-8 decode and error behavior for non-empty slices and preserve the argCount/stop condition, then continue to use shellQuoteIfNeeded(_) on all preserved entries.Sources/Workspace.swift (1)
661-671:⚠️ Potential issue | 🟠 MajorUse
panelWasRemoteBackedto choose the restore startup path.Line 661 computes the per-panel backend, but Lines 666-671 still route every restore through
newTerminalSurface(...), and Lines 8679-8688 still pickinitialCommandfrom the workspace-wideremoteTerminalStartupCommand(). Mixed workspaces can still restore the wrong terminal type depending on the workspace’s current remote state.Based on learnings, `createPanel(from:inPane:)` should use the per-panel `isRemoteBacked` flag during restore so local terminals attached to remote-configured workspaces stay local.🛠️ Suggested change
guard let terminalPanel = newTerminalSurface( inPane: paneId, focus: false, workingDirectory: workingDirectory, + useWorkspaceRemoteStartupCommand: panelWasRemoteBacked, startupEnvironment: replayEnvironment, initialInput: commandToRestore ) else { return nil }func newTerminalSurface( inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, + useWorkspaceRemoteStartupCommand: Bool = true, startupEnvironment: [String: String] = [:], initialInput: String? = nil ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let remoteTerminalStartupCommand = + useWorkspaceRemoteStartupCommand ? remoteTerminalStartupCommand() : nil let safeInitialInput = remoteTerminalStartupCommand != nil ? nil : initialInputAlso applies to: 8667-8689
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 661 - 671, The restore logic currently computes panelWasRemoteBacked but always calls newTerminalSurface(...) and later uses workspace-wide remoteTerminalStartupCommand(), which can restore the wrong terminal type; update createPanel(from:inPane:) to branch on panelWasRemoteBacked (the per-panel snapshot.terminal?.isRemoteBacked flag) and choose the per-panel restore path: if panelWasRemoteBacked use the remote/startup flow and remoteTerminalStartupCommand() only for remote-backed panels, otherwise use the local/newTerminalSurface(...) path and validated per-panel command (SessionRestoreCommandSettings.validatedRestoreCommand(snapshot.terminal?.detectedCommand)) so each panel uses its own isRemoteBacked value during restore.
🧹 Nitpick comments (2)
cmuxTests/SessionPersistenceTests.swift (2)
2411-2418: Test name is misleading.The test
testUnicodeHomoglyphAttemptsdoesn't actually test unicode homoglyphs—it only tests basic semicolon chaining with ASCII characters. Consider renaming to match the actual behavior being tested, or add actual homoglyph test cases (e.g., Cyrillicаvs Latinain command names).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 2411 - 2418, The test named testUnicodeHomoglyphAttempts is misleading because it only asserts semicolon chaining behavior; rename the test to something like testSemicolonChainingDisallowed or testDenylistBlocksChainedCommands (update the XCTest method name) OR extend the test to include real homoglyph cases (e.g., use Cyrillic 'а' vs Latin 'a' in the command string) and assert the same behavior via SessionRestoreCommandSettings.isCommandAllowed(_:rawAllowlist:); modify assertions accordingly so the test name matches the actual inputs and intent.
1373-1380: Remove unused variable.
allowliston line 1375 is declared but never used. The test correctly usessafeAllowlistinstead.🧹 Suggested fix
func testPatternMatchingPreservesArgumentSpaces() { // Arguments with multiple spaces should be preserved - let allowlist = "echo *" // Note: echo is blocked by denylist in some contexts, use a safe command let safeAllowlist = "myecho *" XCTAssertTrue(SessionRestoreCommandSettings.isCommandAllowed("myecho hello world", rawAllowlist: safeAllowlist)) XCTAssertTrue(SessionRestoreCommandSettings.isCommandAllowed("myecho 'hello world'", rawAllowlist: safeAllowlist)) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxTests/SessionPersistenceTests.swift` around lines 1373 - 1380, In testPatternMatchingPreservesArgumentSpaces remove the unused local variable allowlist (declared in the test) since the test uses safeAllowlist and allowlist is never referenced; update the function testPatternMatchingPreservesArgumentSpaces to delete the allowlist declaration so only safeAllowlist is used, leaving the assertions that call SessionRestoreCommandSettings.isCommandAllowed unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/SessionPersistence.swift`:
- Around line 526-527: The denylist currently contains entries like "ssh *" and
"mosh *" but does not guard against dangerous ssh options; update the
denylist/validation where "ssh *" and "mosh *" are defined to also reject any
ssh invocation that includes LocalCommand, PermitLocalCommand, or ProxyCommand
(e.g., detect "-o LocalCommand=", "-o PermitLocalCommand=", "-o ProxyCommand="
or any appearance of those keywords) so commands like ssh -o LocalCommand='...'
host are denied; apply the same change to the other denylist block mentioned
(lines ~633–658) so both definitions validate option strings, and ensure the
validation logic trims/normalizes args before matching.
In `@Sources/Workspace.swift`:
- Around line 7988-7999: The promotion to a remote-backed terminal currently
removes the stale local TTY but leaves any local port metadata in
surfaceListeningPorts, causing stale listeners to remain; update the promotion
path (e.g., in trackRemoteTerminalSurface(_:) or immediately before it is
called) to remove the surfaceListeningPorts entry for the panel (use the same
panel identifier, e.g., initialPanelId / panelId) by calling
surfaceListeningPorts.removeValue(forKey: panelId) so local listener metadata is
cleared when a surface becomes remote-backed; keep the existing comment about
not clearing TTYs but ensure port metadata is explicitly removed.
---
Duplicate comments:
In `@Sources/SessionPersistence.swift`:
- Around line 1236-1280: The loop parsing argv currently increments argCount for
empty NUL-separated entries but omits adding empty strings to args, so sequences
like tool "" --flag become tool --flag; modify the parsing loop (the block that
uses variables buffer, offset, argc, args, argCount, start) to append an empty
string to args when i == start (i.e., when a zero byte terminates an empty
argument) instead of skipping it; keep the existing UTF-8 decode and error
behavior for non-empty slices and preserve the argCount/stop condition, then
continue to use shellQuoteIfNeeded(_) on all preserved entries.
In `@Sources/Workspace.swift`:
- Around line 661-671: The restore logic currently computes panelWasRemoteBacked
but always calls newTerminalSurface(...) and later uses workspace-wide
remoteTerminalStartupCommand(), which can restore the wrong terminal type;
update createPanel(from:inPane:) to branch on panelWasRemoteBacked (the
per-panel snapshot.terminal?.isRemoteBacked flag) and choose the per-panel
restore path: if panelWasRemoteBacked use the remote/startup flow and
remoteTerminalStartupCommand() only for remote-backed panels, otherwise use the
local/newTerminalSurface(...) path and validated per-panel command
(SessionRestoreCommandSettings.validatedRestoreCommand(snapshot.terminal?.detectedCommand))
so each panel uses its own isRemoteBacked value during restore.
---
Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 2411-2418: The test named testUnicodeHomoglyphAttempts is
misleading because it only asserts semicolon chaining behavior; rename the test
to something like testSemicolonChainingDisallowed or
testDenylistBlocksChainedCommands (update the XCTest method name) OR extend the
test to include real homoglyph cases (e.g., use Cyrillic 'а' vs Latin 'a' in the
command string) and assert the same behavior via
SessionRestoreCommandSettings.isCommandAllowed(_:rawAllowlist:); modify
assertions accordingly so the test name matches the actual inputs and intent.
- Around line 1373-1380: In testPatternMatchingPreservesArgumentSpaces remove
the unused local variable allowlist (declared in the test) since the test uses
safeAllowlist and allowlist is never referenced; update the function
testPatternMatchingPreservesArgumentSpaces to delete the allowlist declaration
so only safeAllowlist is used, leaving the assertions that call
SessionRestoreCommandSettings.isCommandAllowed unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c1def629-bab7-4f28-9e66-c3e19144d9f4
📒 Files selected for processing (3)
Sources/SessionPersistence.swiftSources/Workspace.swiftcmuxTests/SessionPersistenceTests.swift
| "ssh *", | ||
| "mosh *", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, locate and examine Sources/SessionPersistence.swift
fd "SessionPersistence.swift" -x wc -l {}Repository: manaflow-ai/cmux
Length of output: 99
🏁 Script executed:
# Read the denylist definition and surrounding context (around line 526)
sed -n '510,570p' Sources/SessionPersistence.swift | cat -nRepository: manaflow-ai/cmux
Length of output: 2626
🏁 Script executed:
# Also check the second range mentioned (633-658)
sed -n '620,670p' Sources/SessionPersistence.swift | cat -nRepository: manaflow-ai/cmux
Length of output: 2407
🏁 Script executed:
# Search for the actual denylistContains definition and its full content
rg -n "denylistContains\s*=" Sources/SessionPersistence.swift -A 20Repository: manaflow-ai/cmux
Length of output: 988
🏁 Script executed:
# Check for command matching logic and how denylists are applied
rg -n "denylistContains|commandMatchesPattern" Sources/SessionPersistence.swift -B 2 -A 5Repository: manaflow-ai/cmux
Length of output: 1847
🏁 Script executed:
# Get the complete denylistContains array
sed -n '631,750p' Sources/SessionPersistence.swift | cat -nRepository: manaflow-ai/cmux
Length of output: 5501
🏁 Script executed:
# Also check if there's any case-insensitive matching or lowercasing in the validation logic
rg -n "localcommand|permitlocalcommand|proxycommand" Sources/SessionPersistence.swift -iRepository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
# Check the isCommandAllowed validation flow to understand how denylist is applied
rg -n "isCommandAllowed|denylistContains" Sources/SessionPersistence.swift -B 3 -A 10Repository: manaflow-ai/cmux
Length of output: 4841
🏁 Script executed:
# Verify the exact line numbers for the denylist segment mentioned in "Also applies to: 633-658"
sed -n '633,658p' Sources/SessionPersistence.swift | cat -nRepository: manaflow-ai/cmux
Length of output: 1268
🏁 Script executed:
# Check if there are any other places in the codebase where these SSH options are validated
rg -n "localcommand|permitlocalcommand|proxycommand" -i --type swiftRepository: manaflow-ai/cmux
Length of output: 203
🏁 Script executed:
# Check the context around those lines in TerminalSSHSessionDetector.swift
sed -n '450,470p' Sources/TerminalSSHSessionDetector.swift | cat -nRepository: manaflow-ai/cmux
Length of output: 980
🏁 Script executed:
# Also check what class/function these are in and their purpose
sed -n '440,480p' Sources/TerminalSSHSessionDetector.swift | cat -nRepository: manaflow-ai/cmux
Length of output: 1700
🏁 Script executed:
# Search for proxycommand anywhere in the codebase
rg -n "proxycommand" -i --type swiftRepository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
# Check what filteredSSHOptionKeys is used for - understand its purpose
rg -n "filteredSSHOptionKeys" Sources/TerminalSSHSessionDetector.swift -B 5 -A 10Repository: manaflow-ai/cmux
Length of output: 1288
Add SSH helper-execution options to denylist.
The allowlist includes ssh *, but the denylist does not block LocalCommand, PermitLocalCommand, or ProxyCommand. A command like ssh -o LocalCommand='touch /tmp/cmux' host will pass validation and be restored on subsequent session startup, re-executing the embedded local command.
🔒 Suggested hardening
private static let denylistContains = [
// API keys and tokens
"--api-key=", "--api-key ",
"--apikey=", "--apikey ",
@@
// SSH password wrappers and inline credentials
"sshpass ",
":@", // user:pass@host syntax
+ // SSH options that execute local helpers or replace the local transport
+ "localcommand=", "localcommand ",
+ "permitlocalcommand=", "permitlocalcommand ",
+ "proxycommand=", "proxycommand ",
// Generic auth
"--credentials=", "--credentials ",
"--auth=", "--auth ",Also applies to: 633–658
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/SessionPersistence.swift` around lines 526 - 527, The denylist
currently contains entries like "ssh *" and "mosh *" but does not guard against
dangerous ssh options; update the denylist/validation where "ssh *" and "mosh *"
are defined to also reject any ssh invocation that includes LocalCommand,
PermitLocalCommand, or ProxyCommand (e.g., detect "-o LocalCommand=", "-o
PermitLocalCommand=", "-o ProxyCommand=" or any appearance of those keywords) so
commands like ssh -o LocalCommand='...' host are denied; apply the same change
to the other denylist block mentioned (lines ~633–658) so both definitions
validate option strings, and ensure the validation logic trims/normalizes args
before matching.
|
Regarding Cubic's P2 about 'Allowlisted commands persisted with full arguments including tokens/credentials': This is already addressed by the denylist. The
Commands containing any of these patterns are never cached or persisted, regardless of allowlist status. |
|
@cubic please review |
All Review Comments AddressedGreptile Issues (Apr 2)
CodeRabbit Issues (Apr 2-3)
Cubic Issues (Apr 3-6)
Simplifications Made
All conversations can be resolved. |
a08786e to
01aed04
Compare
|
@austinywang — this is ready for your review when you have a chance. Part 2 (Settings UI + translations) is on hold pending your feedback on the approach here. |
Resolve 10 merge conflicts across 5 files where auto-restore commands (PR manaflow-ai#2545) and restorable agent resume (main) both use the new initialInput parameter. Key merge decisions: - SessionTerminalPanelSnapshot: keep all three new fields (detectedCommand, isRemoteBacked, agent) - TerminalSurface.normalizedInitialInput: unify newline conventions between callers (agent resume sends cmd+\n, restore sends bare cmd) with defense-in-depth injection prevention - Workspace restore: agent resume takes precedence over detected command via combinedInitialInput coalescing - Remove duplicate initialInput property (main let + PR var) - Keep PR safeInitialInput gating for remote-backed terminals Add TerminalSurfaceNormalizedInitialInputTests to guard the newline normalization contract.
Summary
Auto-restore safe terminal commands after a session restore (Sparkle update,
manual quit/relaunch). On quit, cmux detects the foreground process in each
terminal and stores it in the session snapshot. On restore, the command is
typed into the fresh shell automatically.
initialInputonTerminalSurface/TerminalPanel— one-shot startuptyping, distinct from
initialCommand(which replaces the shell entirely)SessionForegroundProcessDetector— foreground process detection viaps+ process group matching, run off main thread withos_unfair_lockSessionForegroundProcessCache— async refresh during autosaves,blocking with 2s timeout on quit; only caches allowlisted commands
SessionRestoreCommandSettings— denylist runsfirst; blocks dangerous executables, credential patterns, CRLF injection,
and absolute-path bypasses
detectedCommand+isRemoteBackedper panelPart 1 of 2 — core infrastructure. Settings UI + translations in part 2,
pending feedback here.
How It Works
stored in
session-<bundleId>.json(Application Support)initialInputtypes the command + Enter into the new shellrm,git push --force, credential args,and similar before the allowlist is even consulted
Testing
credential redaction, snapshot round-trip
opencode, quit, relaunch → command auto-restartsrm -rf /tmp/test, quit, relaunch → not restoredRelated